2주차. RAG 기초 — 로컬 임베딩·문서 파싱·청킹·벡터 검색
출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(프리렉 2025) 2장의 실전 라이브코딩 강의(2주차, NewRunDev) | 이론 backbone:
book/Master_LangChain_LLM_Service/2장/study_notes.md이 주차는 원서 2장의 개념(임베딩 → 로드 → 분할 → 저장 → 검색 → 생성)을 그대로 따라가되, 모든 외부 API 의존을 걷어내고 로컬 인프라로 재구현한다. 책이 OpenAI 유료 API로 보여준 자리마다, 강의는 LM Studio 로컬 임베딩 서버·원격 ChromaDB 컨테이너·로컬 LLM으로 바꿔 끼운다. 그 과정에서 책에는 없는 프로덕션 급 디테일(인스트럭션 임베딩, 정규식 전처리, cosine 공간 지정, Docker 배포)이 드러난다.
학습 목표
이 주차를 끝내면 다음을 할 수 있다.
- OpenAI 호환 엔드포인트(
base_url·api_key)로 LM Studio 로컬 임베딩 서버를 랭체인OpenAIEmbeddings에 연결하고,check_embedding_ctx_length=False가 왜 필수인지 설명한다. - 코사인 유사도를 직접 계산하고, Qwen3 계열 임베딩의 인스트럭션(Instruct) 포맷으로 같은 쿼리의 검색 결과를 의도적으로 갈라놓는다.
WebBaseLoader·PyPDFLoader·PyMuPDFLoader·PDFPlumberLoader·CSVLoader를 소스와 요구(속도·메타데이터·표 구조)에 맞게 선택한다.RecursiveCharacterTextSplitter를 쓰기 전에 정규식으로 머리글·페이지번호·캡션을 제거하고, 한국어 문장부호를 구분자에 넣어 청크 품질을 끌어올린다.- 원격 ChromaDB 서버(
HttpClient)에 벡터를 적재하되 비-OpenAI 임베딩에 맞춰hnsw:space="cosine"를 지정한다. - LCEL 체인(
retriever | prompt | llm | parser)에 로컬 LLM과 대화 이력을 붙이고, Streamlit으로 Docker 컨테이너 안에서 서비스한다.
전체 흐름도
[LM Studio 로컬 임베딩 서버 :12345] [ChromaDB 컨테이너 :8000] [LM Studio 로컬 LLM]
│ OpenAI 호환 /v1 │ HttpClient │ ChatOpenAI(base_url)
▼ ▼ ▼
[문서]
↓ (1) 로드 — WebBaseLoader / PyPDFLoader / PyMuPDFLoader / CSVLoader
[원문 Document]
↓ (2) 전처리 — clean_page(정규식: 머리글·페이지번호·캡션 제거) ← 책에 없는 단계
[정제 Document]
↓ (3) 분할 — RecursiveCharacterTextSplitter(한국어 구분자 + overlap)
[청크 리스트]
↓ (4) 임베딩 — OpenAIEmbeddings(로컬 Qwen3, 1024차원)
[벡터 리스트]
↓ (5) 저장 — Chroma.from_documents(client=원격, hnsw:space=cosine)
[원격 인덱스]
↑ (6) 검색 — as_retriever() / similarity_search (+ 인스트럭션으로 관점 전환)
[관련 청크]
↓ (7) 생성 — RunnablePassthrough.assign(context) | prompt | 로컬 LLM | parser
[최종 답변] → (8) Streamlit UI로 서비스
인덱싱(1~5)은 사전 준비, 쿼리(6~7)는 질문 처리, 배포(8)는 서비스화 단계다. 책과 달라지는 지점은 (2) 전처리, (4)~(5)의 로컬·원격 인프라, (7)의 로컬 LLM이다.
0. 사전 필수 용어
- 임베딩(Embedding) — 텍스트를 의미가 담긴 실수 벡터로 바꾸는 과정. 비슷한 의미는 벡터 공간에서 가까이 놓인다.
- 코사인 유사도(Cosine Similarity) — 두 벡터의 방향 유사성.
(A·B)/(‖A‖‖B‖). 1에 가까울수록 의미가 비슷하다. 문장 임베딩은 보통 0~1 범위 값이 나온다. - OpenAI 호환 엔드포인트 — OpenAI API와 같은 요청/응답 규격을 흉내 내는 서버. LM Studio가 이 규격으로 로컬 모델을 노출하므로, 랭체인의
OpenAIEmbeddings·ChatOpenAI에base_url만 바꿔 그대로 연결할 수 있다. - LM Studio — 로컬 PC에서 GGUF 등 오픈 모델을 띄우고 OpenAI 호환 API로 서빙하는 데스크톱 도구. 이 강의의 임베딩·LLM 백엔드.
- 인스트럭션 임베딩(Instruction-tuned Embedding) — 쿼리 앞에 "무엇을 찾아라"는 지시문을 붙여 같은 문장이라도 검색 의도를 바꿔 임베딩하는 기법. Qwen3-Embedding 계열이 공식 지원한다.
check_embedding_ctx_length—OpenAIEmbeddings가 입력을 토큰 ID 배열로 미리 잘라 보낼지, 원문 문자열 그대로 보낼지 정하는 플래그. 로컬 모델에는False가 필수(아래 1.1).- HNSW /
hnsw:space— Chroma가 근사 최근접 이웃 검색에 쓰는 그래프 인덱스와 그 거리 척도(cosine/l2/ip). 임베딩 종류에 맞게 골라야 한다. - 청크(Chunk) — 긴 문서를 잘라낸 조각. 너무 작으면 정보 부족, 너무 크면 검색 정확도 하락.
- chunk_overlap — 인접 청크가 공유하는 문자 수. 청크 경계에서 문맥이 잘리는 것을 완화한다.
- LCEL(LangChain Expression Language) —
|연산자로 컴포넌트를 선언적으로 잇는 랭체인 문법.
1. 로컬 임베딩 서버로 시작하기 (책은 OpenAI, 강의는 LM Studio)
책 2장은 OpenAIEmbeddings(model="text-embedding-3-small") 한 줄로 시작한다. 유료 API 키가 필요하고, 문장 하나마다 과금된다. 강의는 같은 클래스를 쓰되 로컬에서 띄운 Qwen3 임베딩 모델을 가리키게 만든다. 코드 구조는 그대로, 접속 대상만 바꾸는 것이 핵심이다.
1.1 연결 설정 — 세 가지 필수 인자
from langchain_openai import OpenAIEmbeddings # 구 langchain.embeddings 경로는 deprecated
embeddings = OpenAIEmbeddings(
model="Qwen3-Embedding-0.6B-GGUF:sl", # LM Studio에 로드된 모델 라벨 그대로
base_url="http://host.docker.internal:12345/v1", # 컨테이너→호스트 LM Studio. 끝의 /v1 누락이 최다 실수
api_key="lm-studio", # LM Studio는 키를 검증하지 않음 → 더미로 형식만 충족
check_embedding_ctx_length=False, # 중요: 미설정 시 tiktoken이 토큰ID로 잘라 보내 로컬 모델이 원문을 못 받음
)
세 인자가 각각 무엇을 막아주는지가 이 셀의 전부다.
base_url의/v1— LM Studio의 OpenAI 호환 라우트는/v1밑에 있다. 여기서 컨테이너 안 노트북이 호스트 PC의 LM Studio에 닿으려면localhost가 아니라host.docker.internal을 써야 한다(컨테이너의localhost는 컨테이너 자신).api_key— OpenAI 클라이언트는 키가 없으면 요청 자체를 만들지 않으므로, 검증하지 않는 서버라도 아무 문자열이나 채워 형식을 맞춘다.check_embedding_ctx_length=False— 기본값True면 랭체인이 tiktoken으로 입력을 토큰 ID 배열로 바꿔 잘라 보낸다. OpenAI 서버는 그걸 이해하지만 로컬 GGUF 모델은 원문 문자열을 기대한다.False로 두어야 원문이 그대로 전송된다. 로컬 임베딩에서 결과가 이상하면 가장 먼저 의심할 지점이다.
책 A vs 강의 B — 책은 OpenAI 클라우드 임베딩(과금·1536차원·키 필요). 강의는 로컬 LM Studio(무료·오프라인·데이터 외부 유출 없음). 왜 B인가: 실습을 반복해도 비용이 0이고, 사내 문서를 외부 API에 올리지 않아도 되며, 한국어 특화 오픈 모델을 자유롭게 갈아 끼울 수 있기 때문이다. 대가로 로컬 GPU/RAM과 위 세 인자 같은 접속 디테일을 직접 감당해야 한다.
1.2 차원 확인 — 모델이 달라지면 차원도 달라진다
query_result = embeddings.embed_query('저는 배가 고파요')
print(len(query_result)) # 1024 — ada-002의 1536도, 8B 계열의 4096도 아님
print(query_result[:5])
책의 text-embedding-3-small은 1536차원이지만, 여기 로드된 Qwen3-Embedding-0.6B는 1024차원을 돌려준다. 차원은 모델 고유값이라 임베딩 모델을 바꾸면 벡터 DB도 재구축해야 한다. 저장할 때 쓴 모델과 검색할 때 쓴 모델이 다르면 벡터가 서로 다른 공간에 놓여 유사도 비교가 무의미해진다.
1.3 코사인 유사도로 검색 흉내 내기 (판다스 버전)
벡터 DB를 붙이기 전에, 넘파이만으로 "질문과 가장 비슷한 문장 찾기"를 손으로 만들어 본다. 이 코드가 이후 벡터 DB가 대신 해줄 일의 축소판이다.
import numpy as np
from numpy import dot
from numpy.linalg import norm
import pandas as pd
data = [
'주식 시장이 급등했어요', '시장 물가가 올랐어요', '전통 시장에는 다양한 물품들을 팔아요',
'부동산 시장이 점점 더 복잡해지고 있어요', '저는 빠른 비트를 좋아해요', '최근 비트코인 가격이 많이 변동했어요',
]
df = pd.DataFrame(data, columns=['text'])
df['embedding'] = df['text'].apply(lambda t: embeddings.embed_query(t)) # 문장마다 서버 1회 호출
def cos_sim(A, B):
return dot(A, B) / (norm(A) * norm(B))
def return_answer_candidate(df, query):
query_embedding = get_embedding(query)
df["similarity"] = df.embedding.apply(
lambda x: cos_sim(np.array(x), np.array(query_embedding))
)
return df.sort_values("similarity", ascending=False).head(3)
return_answer_candidate(df, '과일 값이 비싸다')
# → '시장 물가가 올랐어요'(0.557), '최근 비트코인 가격이...'(0.528), '주식 시장이 급등...'(0.468)
"과일 값이 비싸다"라는 질문에 문장 데이터에는 과일이 하나도 없지만, 물가·가격 상승이라는 의미가 가까운 문장이 위로 올라온다. 이것이 키워드 매칭이 아닌 의미 검색이다. 단, 원하는 "전통 시장"(장소)은 상위에 없다 — 질문이 "비싸다(가격)" 쪽이라 가격 문장에 끌리기 때문이다. 이 한계를 다음 절의 인스트럭션이 해결한다.
2. 인스트럭션 임베딩 — 이 강의의 핵심 (책에 없음)
원서 2장에는 없는, Qwen3-Embedding 계열만의 기능이다. 쿼리 앞에 "이 검색으로 무엇을 찾고 싶은지"를 자연어 지시문으로 붙이면, 같은 쿼리라도 임베딩 방향이 바뀌어 검색 결과가 갈라진다. 검색기를 재학습하지 않고 프롬프트만으로 검색 의도를 조종하는 셈이다.
2.1 공식 포맷과 비대칭 적용
def get_detailed_instruct(task: str, query: str) -> str:
return f'Instruct: {task}\nQuery:{query}' # Qwen 공식 포맷 (Query: 뒤 공백 없음에 주의)
def get_embedding_instruct(text: str, task: str):
return embeddings.embed_query(get_detailed_instruct(task, text))
def return_answer_candidate_instruct(df, query, task):
query_embedding = get_embedding_instruct(query, task) # 쿼리에만 인스트럭션을 입힌다
sim = df.embedding.apply( # 문서는 무인스트럭션 baseline 벡터를 재사용
lambda x: cos_sim(np.array(x), np.array(query_embedding))
)
out = df.assign(similarity=sim) # 원본 df를 오염시키지 않아 비교 실험이 반복 가능
return out.sort_values("similarity", ascending=False).head(3)
가장 미묘한 설계는 비대칭 적용이다. 인스트럭션은 쿼리에만 붙이고, 문서 쪽은 1.3에서 만든 baseline 벡터(df.embedding)를 그대로 재사용한다. 이유는 두 가지다. 첫째, 문서 전체를 인스트럭션마다 다시 임베딩하면 실험 한 번에 문서 수만큼 서버 호출이 더 든다. 둘째, 실제 RAG에서 문서는 인덱싱 시점에 한 번만 임베딩해 저장해 두고, 검색 의도는 쿼리 시점에 매번 바뀐다 — 이 비대칭이 곧 운영 구조와 같다. df.assign(...)으로 새 프레임을 만들어 원본을 건드리지 않는 것도, 여러 인스트럭션을 연달아 비교하기 위한 장치다.
2.2 인스트럭션이 결과를 실제로 가른다
같은 쿼리 '과일 값이 비싸다'에 서로 다른 task를 걸면 상위 문서가 달라진다(노트북 실행 결과).
tasks = {
'A_generic' : 'Given a query, retrieve sentences with semantically similar meaning',
'B_price' : 'Given a statement about rising prices, retrieve sentences about price increases or cost of living',
'C_websearch': 'Given a web search query, retrieve relevant passages that answer the query',
}
for name, task in tasks.items():
print(name, return_answer_candidate_instruct(df, '과일 값이 비싸다', task)[['text','similarity']])
# baseline : 시장 물가(0.557) …
# B_price : 시장 물가(0.639) — 가격 인스트럭션이 물가 문장의 점수를 끌어올림
# C_websearch: 전반적으로 점수 자체가 낮아짐 — 이 task는 이 데이터셋과 결이 안 맞음
여기서 두 가지를 읽어야 한다. B_price는 "가격 상승"이라는 의도를 강화해 물가 문장의 유사도를 baseline 0.557 → 0.639로 밀어 올린다. 반면 C_websearch는 점수를 전반적으로 낮춘다 — 인스트럭션을 붙였다고 항상 좋아지는 게 아니라, task가 데이터·의도와 맞아야 효과가 난다는 뜻이다.
결정적 대비는 "장소를 찾고 싶을 때"다.
commerce_tasks = {
'D_place': 'Given a query, retrieve sentences about marketplaces or places where goods are sold',
'E_buy' : 'Given a query about buying a product, retrieve sentences about where to buy or sell goods',
}
# query="과일을 어디서 살 수 있나요" + D_place → '전통 시장에는 다양한 물품들을 팔아요' 가 1위(0.539)
baseline에서는 끝내 위로 올라오지 못했던 "전통 시장" 문장이, 장소 의도(D_place)를 명시하고 쿼리를 "어디서 살 수 있나요"로 바꾸자 1위로 올라온다. 검색기를 손대지 않고 인스트럭션과 쿼리 표현만으로 검색 의도를 옮긴 것이다.
왜 위험한가 — 인스트럭션은 영어로 쓸 때 모델이 가장 잘 따른다(Qwen3 학습 분포). 포맷도 정확히
Instruct: ...\nQuery: ...여야 하며, 형식이 어긋나면 지시문이 그냥 검색 대상 문장처럼 취급돼 오히려 노이즈가 된다.
3. 문서 로더 — 웹·PDF·CSV를 표준 Document로
랭체인 문서 로더는 소스가 무엇이든 Document(page_content=..., metadata=...)라는 한 형태로 통일해 준다. 덕분에 이후 분할·임베딩·저장 코드는 소스 종류를 몰라도 된다. 이 절은 책 2장과 거의 같으나, 강의는 각 로더의 실측 속도와 메타데이터 차이를 눈으로 보여준다.
3.1 웹 로더 — WebBaseLoader
import os
os.environ["USER_AGENT"] = "MyApp/1.0 (Custom LangChain Application)" # 미설정 시 경고. 서버가 요청자를 식별
from langchain_community.document_loaders import WebBaseLoader
loader = WebBaseLoader("https://docs.smith.langchain.com/")
single_doc = loader.load()
print(single_doc[0].metadata) # {'source', 'title', 'description', 'language'}
loader_multiple = WebBaseLoader([
"https://python.langchain.com/docs/introduction/",
"https://langchain-ai.github.io/langgraph/",
])
docs = loader_multiple.load() # URL 리스트를 넘기면 여러 페이지를 한 번에
HTML을 BeautifulSoup으로 파싱해 본문 텍스트만 뽑고, 제목·언어 등을 메타데이터에 담는다. 로드된 본문은 내비게이션·사이드바 텍스트까지 섞여 들어오므로, 실전에서는 분할 전에 정제(3.x의 정규식 클리닝과 같은 발상)가 필요하다.
3.2 PDF 로더 3종 — 속도와 메타데이터의 트레이드오프
같은 84페이지 PDF를 세 로더로 로드하면 청크 수는 같아도 속도와 메타데이터가 크게 갈린다(노트북 %%time 실측).
from langchain_community.document_loaders import PyPDFLoader, PyMuPDFLoader, PDFPlumberLoader
PDF_PATH = "2024 KB 부동산 보고서.pdf"
PyPDFLoader(PDF_PATH).load_and_split() # 청크 83개 · 약 9.4초 · 메타 기본(source, page)
PyMuPDFLoader(PDF_PATH).load_and_split() # 청크 83개 · 약 1.9초 · 메타 풍부(format, author, total_pages …)
PDFPlumberLoader(PDF_PATH).load_and_split()# 청크 83개 · 약 15.5초 · 표를 줄맞춰 텍스트로 복원
| 로더 | 실측 속도 | 메타데이터 | 표 처리 | 언제 |
|---|---|---|---|---|
PyPDFLoader |
~9.4s | 기본(source, page) | 약함 | 간단·표준 PDF |
PyMuPDFLoader |
~1.9s | 풍부(format·author·file_path 등) | 보통 | 대용량·다수 파일, 속도 우선 |
PDFPlumberLoader |
~15.5s | 풍부(Title·Author 등) | 강함(표 정렬 복원) | 표·구조화 데이터가 핵심일 때 |
세 로더가 같은 표를 어떻게 다르게 뽑는지가 선택 기준이다. PyMuPDFLoader는 표 셀을 값만 나열해 빠르지만 행 구조가 흐트러지고, PDFPlumberLoader는 느린 대신 표의 행·열 배치를 텍스트로 최대한 살린다. 부동산 보고서처럼 금리 표·통계 표가 핵심 정보면 느려도 PDFPlumber가 유리하고, 본문 텍스트가 주면 PyMuPDF의 속도가 이긴다.
3.3 CSV 로더 — 행 단위 vs 표 전체
from langchain_community.document_loaders import CSVLoader, UnstructuredCSVLoader
CSV_PATH = "서울시_부동산_실거래가_정보.csv"
CSVLoader(CSV_PATH).load() # 2001개 — 한 행 = 한 Document
UnstructuredCSVLoader(CSV_PATH, mode='elements').load() # 1개 — 표 전체를 text_as_html로 묶음
CSVLoader는 행마다 "열: 값" 형태의 Document 하나를 만든다(2000행 → 2001개, 헤더 포함). 각 행을 독립적으로 검색하고 싶을 때 쓴다. UnstructuredCSVLoader의 elements 모드는 표 전체를 하나의 Document로 만들고 metadata['text_as_html']에 <table>...</table> 구조를 담는다 — 표의 행·열 관계 자체가 의미를 가질 때(예: LLM에게 표를 통째로 보여주고 추론시킬 때) 유용하다.
4. 텍스트 분할 — 책의 한 줄과 강의의 프로덕션 전처리
책 2장은 RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200) 한 줄로 분할한다. 강의는 그 앞에 정규식 정제 단계를 끼워, 반복되는 머리글·페이지번호·표 캡션을 먼저 걷어낸다. 이것이 청크 품질을 좌우하는, 책에 없는 실전 노하우다.
4.1 분할 전에 청소부터 — clean_page
import re
from langchain_core.documents import Document
HEADER = re.compile(r'2024\s*KB\s*부동산\s*보고서[^\n]*') # 모든 페이지에 반복되는 머리글
PAGENO = re.compile(r'^\s*\d{1,3}\s*$', re.M) # 숫자만 있는 페이지번호 줄
CAPTION = re.compile(r'^\s*(그림|표|자료|주)\s*[ⅠⅡⅢIVX\d][^\n]*$', re.M) # "그림Ⅰ-1", "표Ⅱ-3" 캡션
def clean_page(text: str) -> str:
text = HEADER.sub('', text)
text = CAPTION.sub('', text)
text = PAGENO.sub('', text)
text = re.sub(r'[ \t]+', ' ', text) # 연속 공백 축약
text = re.sub(r'\n{3,}', '\n\n', text) # 과한 빈 줄 축약
return text.strip()
cleaned = []
for p in pages:
body = clean_page(p.page_content)
if len(body) < 50: # 표·그림만 있어 정제 후 거의 빈 페이지는 통째로 스킵
continue
cleaned.append(Document(page_content=body, metadata=dict(p.metadata)))
왜 분할 전에 이걸 하는가. 머리글 "2024 KB 부동산 보고서"가 84페이지에 84번 반복되면, 그 문구가 청크마다 섞여 들어가 임베딩을 오염시키고, 검색 시 아무 질문에나 얕게 걸리는 노이즈가 된다. 페이지번호·캡션도 마찬가지로 의미 없는 토큰이다. 정제 후 50자 미만이 된 페이지는 표·그림만 있던 페이지이므로 아예 버린다. 이 한 단계가 이후 모든 검색 정확도의 바닥을 깔아준다.
4.2 한국어를 아는 분할기
from langchain_text_splitters import RecursiveCharacterTextSplitter # 구 langchain.text_splitter 는 이동됨
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 한 청크 ≈ 하나의 논점
chunk_overlap=80, # 경계에서 문맥이 끊기지 않도록 앞뒤를 겹침
separators=["\n\n", "\n", ". ", "。", "! ", "? ", " ", ""], # 한국어 문장부호(。)까지 우선순위에 포함
keep_separator=True,
)
splits = splitter.split_documents(cleaned)
splits = [d for d in splits if len(d.page_content) >= 80] # 자투리 청크 제거
# 정리 후: 페이지 80 → 청크 215개 | min 83 / avg 374 / max 499
separators는 위에서부터 차례로 시도하는 분할 우선순위다. 먼저 문단(\n\n)으로 자르고, 넘치면 줄, 그다음 문장 끝(.·。·!·?) 순으로 내려간다. 책 기본 구분자(["\n\n","\n"," ",""])에는 문장부호가 없어 문장 한가운데서 잘릴 수 있는데, 여기서는 마침표·물음표·한국어 마침표 。를 넣어 문장 경계에서 끊기게 만든다. 분할 뒤 80자 미만 자투리를 걸러 내면 min 83 / avg 374 / max 499의 고른 청크 분포가 나온다.
책 A vs 강의 B — 책은
chunk_size=1000/overlap=200을 기본값으로 제시. 강의는500/80으로 더 잘게 자른다. 왜 B인가: 로컬 임베딩·로컬 LLM은 컨텍스트 여유가 크지 않고, 짧은 청크일수록 "한 청크 = 한 논점"이 지켜져 검색이 날카로워진다. 정답은 없고 문서·모델에 맞춰 측정하며 조정하는 값이다.
5. 벡터 DB — 로컬 파일이 아니라 원격 ChromaDB 서버
책 2장은 Chroma.from_documents(..., persist_directory="./chroma_db")로 로컬 SQLite 파일에 저장한다. 강의는 Chroma를 독립 컨테이너 서버로 띄우고 HttpClient로 접속한다. 여러 서비스(노트북·Streamlit 앱)가 같은 인덱스를 공유하고, 앱을 재시작해도 인덱스가 살아 있게 하려는 운영 지향 선택이다.
5.1 원격 클라이언트 연결과 대용량 안전장치
import chromadb
chroma_client = chromadb.HttpClient(host="chromadb", port=8000) # host=컨테이너 이름(도커 네트워크 DNS)
print("heartbeat:", chroma_client.heartbeat()) # ns 타임스탬프가 찍히면 연결 정상
embedding_function = OpenAIEmbeddings(
model="Qwen3-Embedding-0.6B-GGUF:sl",
base_url="http://host.docker.internal:12345/v1",
api_key="lm-studio",
check_embedding_ctx_length=False,
chunk_size=16, # 한 요청에 담는 텍스트 수 상한 — LM Studio 과부하/연결 끊김 방지
timeout=60.0, # 0.6B라도 로컬 추론은 느릴 수 있어 여유
max_retries=2, # 일시적 실패 시 자동 재시도
)
heartbeat()는 접속 확인용으로, 나노초 타임스탬프가 돌아오면 서버가 살아 있다는 뜻이다. 임베딩 쪽에 새로 붙은 세 인자는 대용량 적재의 안전장치다. chunk_size=16은 임베딩 청크 크기가 아니라 한 HTTP 요청에 몇 개의 텍스트를 묶어 보낼지의 배치 상한이다 — 215개 청크를 한꺼번에 밀어 넣으면 로컬 서버가 뻗거나 연결이 끊기므로 16개씩 나눠 보낸다. timeout·max_retries는 로컬 추론 특유의 느림과 간헐적 실패를 흡수한다.
5.2 임베딩 공간을 맞춰야 한다 — hnsw:space="cosine"
from langchain_chroma import Chroma
COLLECTION = "kb_realestate_2024"
try:
chroma_client.delete_collection(COLLECTION) # 재실행 시 같은 문서가 중복 적재되는 것을 방지
except Exception as e:
print("삭제 건너뜀:", e)
vectordb = Chroma.from_documents(
documents=splits,
embedding=embedding_function,
client=chroma_client,
collection_name=COLLECTION,
collection_metadata={"hnsw:space": "cosine"}, # 중요: 비-OpenAI 임베딩은 cosine 권장
)
print('문서의 수:', vectordb._collection.count()) # 215
가장 중요한 한 줄은 collection_metadata={"hnsw:space": "cosine"}다. Chroma의 HNSW 인덱스 기본 거리 척도는 L2(유클리드)인데, 정규화되지 않은 로컬 임베딩을 L2로 비교하면 벡터 크기 차이가 유사도를 왜곡한다. 의미 유사도는 방향으로 판단해야 하므로 명시적으로 코사인 공간을 지정한다. 컬렉션을 만들 때 정해지는 값이라 나중에 바꾸려면 재구축해야 한다. 그리고 from_documents는 부를 때마다 새로 쌓기만 하므로(책에서 지적한 중복 함정), 재실행 전에 delete_collection으로 비운다.
# 검색은 로컬 Chroma와 동일한 인터페이스
question = "수도권 주택 매매 전망"
top_docs = vectordb.similarity_search(question, k=2)
5.3 인스트럭션 검색을 벡터 DB에 적용 — 관점을 바꾸는 검색
2절의 인스트럭션 기법을 이제 실제 벡터 DB 검색에 얹는다. 같은 질문에 인스트럭션만 바꿔, 어떤 청크가 위로 올라오는지 비교한다.
def instruct_query(task: str, query: str) -> str:
return f"Instruct: {task}\nQuery: {query}"
def search_rows(task, question, k=5):
q = instruct_query(task, question) if task else question
hits = vectordb.similarity_search_with_relevance_scores(q, k=k) # 점수까지 함께 반환
return [(d.metadata.get("page"), round(s, 3), d.page_content.replace("\n"," ")) for d, s in hits]
노트북은 이 도구로 네 종류의 "관점 분기"를 실증한다.
- 시멘틱 분기 — 쿼리
"전세"하나에가격통계/리스크/정책제도인스트럭션을 걸면, 각각 통계 수치가 있는 페이지(p2·36)·전세사기 리스크 페이지(p9·10)·정책 페이지로 top 청크가 갈린다. - 이해관계자 전환 —
"지금 집을 사도 될까?"에무주택실수요(구입부담 PIR/DSR, p26)·다주택투자(유망 투자처, p40·41)·정책당국(시장안정·시스템 리스크, p80·29) 인스트럭션을 걸면 같은 질문이 완전히 다른 근거 페이지를 가져온다. - 극성(polarity) 분리 —
"2024년 수도권 주택가격 방향"에하방위험/상방요인을 걸어 비관·낙관 서술을 갈라 뽑는다. - 사실 유형 분리 —
"공인중개사의 집값 전망"에응답수치(정확한 %)/전망근거(이유·근거)를 걸어 숫자와 논리를 분리 검색한다.
이 실험이 시사하는 바는 크다. 하나의 벡터 인덱스를 그대로 두고, 인스트럭션만 바꿔 "같은 질문을 여러 관점으로 던지는" 다관점 검색을 공짜로 얻는다. 리랭커나 별도 필터 없이, 검색 의도 자체를 프롬프트로 조종하는 것이다.
6. RAG 체인 조립 — 로컬 LLM과 배포
마지막으로 검색기에 로컬 LLM과 대화 이력을 붙여 완결형 RAG 챗봇을 만든다. 책은 ChatOpenAI(model="gpt-4o-mini")(OpenAI 클라우드)를 쓰지만, 강의는 같은 ChatOpenAI 클래스의 base_url을 LM Studio로 돌려 로컬 LLM을 붙인다. 임베딩에서 했던 것과 정확히 같은 치환이다.
6.1 LCEL 체인
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
prompt = ChatPromptTemplate.from_messages([
("system", "당신은 KB 부동산 보고서 전문가입니다. 다음 정보를 바탕으로 답변하세요.\n컨텍스트: {context}"),
("placeholder", "{chat_history}"), # 대화 이력이 이 자리에 자동 주입
("human", "{question}"),
])
model = ChatOpenAI(
model="unsloth/gemma-4-e2b-it", # LM Studio에 로드된 로컬 LLM 라벨
base_url="http://host.docker.internal:12345/v1", # 임베딩과 같은 로컬 서버, 다른 모델
api_key="lmstudio",
temperature=0.7,
)
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
base_chain = (
RunnablePassthrough.assign( # 질문으로 먼저 검색 → 결과를 context로 입력에 덧붙임
context=lambda x: format_docs(retriever.invoke(x["question"]))
)
| prompt | model | StrOutputParser()
)
체인의 심장은 RunnablePassthrough.assign(context=...)이다. 사용자 입력 {"question": ...}이 들어오면, 먼저 그 질문으로 검색기를 돌려 상위 3청크를 가져와 format_docs로 하나의 문자열로 합치고, 그 값을 context라는 키로 입력에 얹는다. 그러면 프롬프트의 {context} 자리가 검색 결과로 채워진 채 로컬 LLM에 전달된다. | StrOutputParser()는 모델의 메시지 객체에서 순수 문자열만 뽑아낸다.
6.2 대화 이력 — 세션별 저장소의 함정
store = {}
def get_history(session_id: str):
if session_id not in store: # 세션마다 별도 기록을 만들어 보관
store[session_id] = ChatMessageHistory()
return store[session_id] # 같은 세션이면 항상 같은 기록 객체를 돌려줘야 기억이 유지됨
chain = RunnableWithMessageHistory(
base_chain, get_history,
input_messages_key="question",
history_messages_key="chat_history",
)
RunnableWithMessageHistory는 대화 이력을 자동 관리하지만, 넘기는 함수를 잘못 짜면 조용히 기억을 잃는다. 함정은 lambda session_id: ChatMessageHistory()처럼 부를 때마다 새 빈 기록을 만들어 반환하는 경우다 — 매 요청이 백지 기록을 받아 모델이 직전 대화를 잊는다. 위처럼 store 딕셔너리에 세션별로 담아 두고, 같은 세션이면 같은 객체를 돌려줘야 한다. (이 클래스 자체는 최신 랭체인에서 deprecated이며 LangGraph persistence로 대체 권장 — 아래 최신 동향.)
6.3 Streamlit 배포 — 컨테이너 안에서 서비스
노트북은 완성된 체인을 %%writefile app.py로 파일로 떨군 뒤, 서브프로세스로 Streamlit을 띄우고 헬스체크까지 자동화한다.
import subprocess, sys, urllib.request
proc = subprocess.Popen([ # 노트북 셀에서 백그라운드로 서버 기동
sys.executable, "-m", "streamlit", "run", "app.py",
"--server.port", "8501", "--server.address", "0.0.0.0",
"--server.headless", "true",
"--server.enableCORS", "false", "--server.enableXsrfProtection", "false",
])
urllib.request.urlopen("http://localhost:8501/_stcore/health", timeout=5).read() # b'ok' 면 정상
app.py 안에서는 책과 같은 @st.cache_resource로 무거운 초기화(임베딩·벡터스토어·체인)를 한 번만 실행해 캐싱한다. 컨테이너에서 서비스하므로 --server.address 0.0.0.0으로 외부 바인딩하고, 리버스 프록시(NPM) 뒤에 두기 위해 enableCORS·enableXsrfProtection를 끈다. _stcore/health가 ok를 돌려주면 서버가 뜬 것이다. 정지는 pkill -f "streamlit run"으로 포트를 회수한다.
책 A vs 강의 B — 책은 코랩+pyngrok으로 임시 외부 노출. 강의는 Docker 컨테이너+0.0.0.0 바인딩+헬스체크로 상시 서비스에 가깝게 배포한다. 왜 B인가: 원격 ChromaDB·로컬 LLM과 같은 도커 네트워크 안에서 앱이 돌아야 컨테이너 이름(
chromadb)·host.docker.internal로 서로를 찾을 수 있기 때문이다.
최신 동향 (검증 2026-07-21)
아래는 노트북 실행 시점에 랭체인이 실제로 출력한 deprecation 경고와 import 경로 변화에 근거한다(관측 사실). 시간에 따라 바뀌므로 사용 전 공식 문서 재확인 권장.
langchain-communitysunset — 노트북 실행 중 "langchain-communityis being sunset and is no longer actively maintained"라는 경고가 출력된다. 문서 로더·ChatMessageHistory등은 점차 독립 통합 패키지(langchain-<provider>)로 옮겨가는 흐름이다.RunnableWithMessageHistorydeprecated — 6.2의 대화 이력 클래스는 노트북에서 "Use LangGraph's built-in persistence instead" 경고를 낸다. 신규 구현은 LangGraph의 checkpointer(persistence)로 대화 상태를 관리하는 것이 권장 방향이다.- import 경로 이동 — 임베딩은
langchain.embeddings→langchain_openai, 텍스트 분할기는langchain.text_splitter→langchain_text_splitters로 옮겨졌다(노트북 주석에 명시). 구 경로는 deprecated이다. - 로컬·오픈 임베딩의 부상 — 이 강의처럼 OpenAI 유료 임베딩 대신 Qwen3-Embedding 같은 오픈 모델을 LM Studio로 로컬 서빙하는 방식이 비용·프라이버시·한국어 성능 면에서 실용 대안으로 자리 잡았다. 인스트럭션 임베딩 지원은 오픈 모델 쪽의 뚜렷한 강점이다.
클릭하거나 Space를 눌러 뒤집기